feat(stats): show Plausible visitors chart and daily-impl timeline#6608
Conversation
- Add GET /insights/visitors that queries the Plausible Stats API (POST /api/v2/query, time:day dimension) for unique visitors over the last 30 days. Cached 1h via stale-while-revalidate to stay well under Plausible's 600 req/h limit; returns a zero-filled series when PLAUSIBLE_API_KEY is unset or the upstream call fails. - Add daily_impls (last 30 days, zero-filled) to /insights/dashboard so the public stats timeline mirrors the debug page activity strip. - Replace the plain Plausible link at the top of StatsPage with a 30-day visitors bar chart styled like the rest of the page; keep a "more →" link out to plausible.io/anyplot.ai for deeper analytics. - Swap the monthly timeline for the new daily updated-implementations bar chart so visitors see catalog activity at a glance.
CI's `ruff format --check` flagged minor whitespace in the visitors block.
There was a problem hiding this comment.
Pull request overview
Adds public-facing analytics to /stats by introducing a Plausible-backed visitors time series endpoint and switching the dashboard timeline visualization to a last-30-days “implementations updated” strip, with supporting config/docs/tests and Cloud Run wiring.
Changes:
- Add
GET /insights/visitors(cached) to query Plausible Stats API v2 and return a last-30-days visitors series. - Extend
/insights/dashboardwithdaily_impls(last 30 days, zero-filled) and update the StatsPage to render both the visitors chart and daily timeline. - Add configuration knobs/documentation and update unit/frontend tests; wire
PLAUSIBLE_API_KEYvia Cloud Build secrets.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
api/routers/insights.py |
Adds Plausible visitors endpoint and extends dashboard response with daily_impls. |
core/config.py |
Introduces Plausible Stats API settings (PLAUSIBLE_*). |
app/src/pages/StatsPage.tsx |
Renders visitors bar chart and switches timeline to daily 30-day updates. |
app/src/pages/StatsPage.test.tsx |
Updates StatsPage test fixtures for the new dashboard shape. |
tests/unit/api/test_routers.py |
Adds unit tests for /insights/visitors fallback and parsing behavior. |
docs/reference/plausible.md |
Documents backend consumption of Plausible Stats API for the stats page. |
.env.example |
Documents Plausible-related environment variables. |
api/cloudbuild.yaml |
Wires PLAUSIBLE_API_KEY from Secret Manager into Cloud Run deploy. |
Comments suppressed due to low confidence (1)
app/src/pages/StatsPage.tsx:374
- This timeline chart renders a non-zero bar even when point.count is 0 (min 3% height + minHeight 2). The debug page’s activity strip (which this claims to mirror) renders 0-height bars for zero days. To truly mirror the debug behavior and avoid implying activity on zero days, apply a conditional min height/percent only when count > 0.
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 0.25, height: 70, overflow: 'hidden' }}>
{dailyImpls.map(point => (
<Tooltip key={point.date} title={`${point.date}: ${point.count} updated`} arrow>
<Box sx={{
flex: 1,
height: `${Math.max((point.count / maxDaily) * 100, 3)}%`,
bgcolor: colors.primaryDark,
opacity: 0.5,
borderRadius: '2px 2px 0 0',
minHeight: 2,
'&:hover': { opacity: 0.8 },
| <Box sx={{ mt: 1, mb: 3 }}> | ||
| <Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', mb: 0.5 }}> | ||
| <Typography sx={{ fontFamily: typography.fontFamily, fontSize: fontSize.xs, color: semanticColors.mutedText }}> | ||
| unique visitors · last 30 days{visitors !== null && visitorPoints.length > 0 ? ` · ${formatNum(totalVisitors)} total` : ''} |
| {visitors === null ? ( | ||
| <Box sx={{ height: 70, display: 'flex', alignItems: 'center' }}> | ||
| <Typography sx={{ fontFamily: typography.fontFamily, fontSize: fontSize.xxs, color: semanticColors.mutedText }}> | ||
| loading visitor data... | ||
| </Typography> | ||
| </Box> | ||
| ) : visitorPoints.length === 0 ? ( | ||
| <Box sx={{ height: 70, display: 'flex', alignItems: 'center' }}> | ||
| <Typography sx={{ fontFamily: typography.fontFamily, fontSize: fontSize.xxs, color: semanticColors.mutedText }}> | ||
| visitor data unavailable — see plausible.io/anyplot.ai | ||
| </Typography> | ||
| </Box> |
| async def _fetch_plausible_visitors() -> VisitorsResponse: | ||
| """Query the Plausible Stats API v2 for unique visitors per day (last 30d). | ||
|
|
||
| Returns an empty series when the API key is not configured or the upstream | ||
| call fails — the stats page treats this as "no data" rather than erroring. | ||
| The response is zero-filled so the frontend can render a stable 30-bar | ||
| chart even on days Plausible has not seen any visitors. | ||
| """ | ||
| today = datetime.now(timezone.utc).date() | ||
| zero_filled = [ | ||
| VisitorPoint(date=(today - timedelta(days=offset)).isoformat(), visitors=0) for offset in range(29, -1, -1) | ||
| ] |
| daily_impls: [ | ||
| { date: '2026-04-14', count: 3 }, | ||
| { date: '2026-04-15', count: 5 }, | ||
| { date: '2026-04-16', count: 0 }, | ||
| ], |
| plausible_api_key: str | None = None | ||
| """Plausible Analytics Stats API key (Bearer token) used by /insights/visitors | ||
| to fetch unique visitors per day for the public stats page. When unset, the | ||
| endpoint returns an empty result set so the frontend can degrade gracefully.""" |
| # When unset, /insights/visitors returns an empty series (the bar chart shows | ||
| # "no data" instead of failing). |
| upstream call fails, the endpoint returns a zero-filled 30-day series | ||
| and the frontend renders a "visitor data unavailable" placeholder | ||
| instead of erroring. The dashboard endpoint is unaffected because | ||
| visitors load on a separate fetch. |
| # PLAUSIBLE_API_KEY: bearer token for the Plausible Stats API (powers | ||
| # /insights/visitors on the public stats page). The Secret Manager | ||
| # entry must exist before the first deploy that includes this line — | ||
| # create it with: gcloud secrets create PLAUSIBLE_API_KEY --data-file=- | ||
| - "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest,PLAUSIBLE_API_KEY=PLAUSIBLE_API_KEY:latest" |
| class DailyImplPoint(BaseModel): | ||
| """Implementation updates on a single day (last-30-days timeline).""" | ||
|
|
||
| date: str # ISO "YYYY-MM-DD" | ||
| count: int | ||
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
- Backend now returns `points: []` when PLAUSIBLE_API_KEY is unset or the upstream call fails, instead of a 30-point zero-filled series. The frontend already distinguishes empty (placeholder) from real-zeros (chart), so this restores the documented degradation behavior — an all-zero chart was being shown where the "visitor data unavailable" placeholder should have appeared. - Update docstrings/comments in core/config.py, .env.example, docs/reference/plausible.md, and api/routers/insights.py to describe the empty-list contract accurately. - Relabel the visitors total from "X total" to "X daily-uniques sum" since summing per-day uniques over-counts returning visitors and is not the true 30-day unique-visitor count. - Clarify DailyImplPoint's docstring: it intentionally uses `count` (matching the existing TimelinePoint) rather than debug's `impls_updated`, since the two have different consumers. - Test mock now routes by URL so the visitors fetch isn't silently shadowed by the dashboard payload, plus new assertions for the visitors header/total label and the unavailable placeholder. - Add a backend test for the upstream-failure branch.
| def test_visitors_parses_plausible_response(self, client: TestClient) -> None: | ||
| """Visitor counts from Plausible should be merged into the zero-filled 30-day series.""" | ||
| from datetime import datetime as _dt | ||
| from datetime import timezone as _tz | ||
|
|
||
| today_iso = _dt.now(_tz.utc).date().isoformat() | ||
|
|
| resp.raise_for_status() | ||
| data = resp.json() | ||
| except Exception as e: | ||
| logger.warning("Plausible visitors fetch failed (returning empty series): %s", e) |
| - **Graceful degradation**: When `PLAUSIBLE_API_KEY` is unset or the | ||
| upstream call fails, the endpoint returns `points: []` (empty list). | ||
| The frontend distinguishes this from "real zeros" — an empty list | ||
| triggers the "visitor data unavailable" placeholder, while a non-empty | ||
| list with low/zero values renders the normal 30-bar chart. The | ||
| dashboard endpoint is unaffected because visitors load on a separate | ||
| fetch. |
| # PLAUSIBLE_API_KEY: bearer token for the Plausible Stats API (powers | ||
| # /insights/visitors on the public stats page). The Secret Manager | ||
| # entry must exist before the first deploy that includes this line — | ||
| # create it with: gcloud secrets create PLAUSIBLE_API_KEY --data-file=- | ||
| - "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest,PLAUSIBLE_API_KEY=PLAUSIBLE_API_KEY:latest" |
| function mockFetchSuccess(visitorsPayload: { points: Array<{ date: string; visitors: number }> } | null = mockVisitors) { | ||
| vi.stubGlobal( | ||
| 'fetch', | ||
| vi.fn().mockResolvedValue({ | ||
| ok: true, | ||
| json: () => Promise.resolve(mockDashboard), | ||
| vi.fn().mockImplementation((url: string) => { | ||
| if (url.includes('/insights/visitors')) { | ||
| return Promise.resolve({ | ||
| ok: visitorsPayload !== null, | ||
| json: () => Promise.resolve(visitorsPayload ?? { points: [] }), | ||
| }); | ||
| } | ||
| return Promise.resolve({ | ||
| ok: true, | ||
| json: () => Promise.resolve(mockDashboard), | ||
| }); | ||
| }), | ||
| ); |
- Preserve stack context on Plausible fetch failure by switching the warning log to `exc_info=True` (matches the cache.py pattern). - Stop the test_visitors_parses_plausible_response test from being flaky around UTC midnight by patching `datetime` in the module under test to a frozen value (so the endpoint's "today" matches the date the fake Plausible response references). - Add `afterEach(vi.unstubAllGlobals)` to StatsPage.test.tsx so the stubbed `fetch` doesn't leak into other test suites in the same vitest worker.
- Move the Plausible link into the canonical SectionHeader link slot as plausible.view() so it matches the libraries.all() / map.explore() / specs.all() style used across the site; SectionHeader gains a linkHref prop for external URLs (target=_blank, fires external_link). - Align the visitors window with Plausible's default 28-day report so totals here line up with plausible.io/anyplot.ai. Bring the dashboard daily_impls timeline to 28d too so the two bar strips read side-by-side. Caption switched from "daily-uniques sum" -> "total". - Document the now-provisioned PLAUSIBLE_API_KEY in plausible-auditor.md: env -> .env -> Secret Manager fallback chain, never-block contract, v1 + v2 connectivity check, and broaden allowed endpoints to include the v2 query POST the backend actually uses for /insights/visitors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Expanded list of supported languages - Clarified notes on language server requirements - Added sections for excluded and included tools - Introduced additional workspace folder paths for cross-package reference
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
app/src/components/SectionHeader.tsx:86
- The new
linkHreftracking setsdestinationto the full URL. Elsewhere in the app,destinationvalues are low-cardinality identifiers (e.g.github_releases,library_docs), which keeps analytics dimensions tidy. Consider tracking a stable identifier here as well (e.g. hostname or a caller-provided destination key), rather than the full URL string.
href={linkHref}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackEvent('external_link', { source: 'section_header', destination: linkHref })}
sx={linkSx}
app/src/components/SectionHeader.tsx:83
linkHrefadds a new rendering + analytics path (external_linkevent) but SectionHeader’s test suite currently only covers thelinkTo/nav_clickbranch. Please add a unit test that renderslinkHref, clicks the link, and asserts the expectedexternal_linktracking props so this behavior doesn’t regress silently.
{linkText && linkHref && !linkTo && (
<Box
component="a"
href={linkHref}
target="_blank"
|
|
||
|
|
||
| class DailyImplPoint(BaseModel): | ||
| """Implementation updates on a single day (last-30-days timeline). |
| linkText?: string; | ||
| /** Internal route (React Router). Mutually exclusive with `linkHref`. */ | ||
| linkTo?: string; | ||
| /** External URL — opens in a new tab. Mutually exclusive with `linkTo`. */ | ||
| linkHref?: string; |
## Summary - Rescues an orphan post-merge commit (`3a955a4`) from `claude/add-plausible-chart-7voOx`. The original PR #6608 was squash-merged, then a Copilot review-feedback fix was pushed to the (closed) branch and never landed on main. - All three changes are still missing on main — verified by diffing `origin/main..origin/claude/add-plausible-chart-7voOx`. ## Changes - **`api/routers/insights.py`** — fix `DailyImplPoint` docstring: `last-30-days` → `last-28-days` to match the visitors-chart window the strip is paired with. - **`app/src/components/SectionHeader.tsx`** — convert `linkTo` / `linkHref` to a discriminated union so callers can no longer pass both. Extract `externalDestination()` helper to send only the hostname to Plausible (low-cardinality dimension) instead of the full URL. - **`app/src/components/SectionHeader.test.tsx`** — add a unit test for the `linkHref` branch and the `external_link` tracking payload. ## Test plan - [x] `yarn test SectionHeader.test.tsx` passes (3/3) - [ ] CI green - [ ] `claude/add-plausible-chart-7voOx` can be deleted after merge Co-authored-by: Claude <noreply@anthropic.com>
Version bump for the v2.4.0 release. Release notes will be attached to the tag once this lands. ## Highlights since v2.3.0 - **R / ggplot2 added as the 10th library** + multi-language pipeline (#6944, #6961, #7052). 30 ggplot2 implementations landed across foundational plot types. - **In-app feedback widget** (#7143). - **Stats page** with Plausible visitors chart + daily-impl timeline (#6608). - **Language across the site**: `/plots?lang=` filtering, cross-language carousel, language in URLs and titles (#7141, #7142, #7144). - **UI polish**: pseudo-function styling for 404 / footer / empty state / library card (#6436); mobile fixes for `/stats`, `/mcp`, breadcrumb + FAB (#6902, #7283). - **Pipeline**: review-retry listener + stuck-jobs watchdog (#6084); daily-regen 2h → hourly (#6943). - **Dependencies**: mypy 1.20→2.1, urllib3 2.6→2.7, authlib bump, react/mui/python-minor groups. - ~1200 implementation regenerations across all 10 libraries. No SemVer-breaking changes. **Full Changelog:** v2.3.0...main 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
more →link still points to the full Plausible dashboard for deeper analytics.timelinefor a 30-day "implementations updated per day" bar chart, visually mirroring the activity strip on the debug dashboard so public visitors can see catalog activity at a glance. (Schema-wise it usescountto matchTimelinePoint, not debug'simpls_updatedfield — seeDailyImplPointdocstring.)GET /insights/visitorsqueries Plausible's Stats API v2 (POST https://plausible.io/api/v2/querywith{ metrics: ["visitors"], date_range: "30d", dimensions: ["time:day"] }), cached 1h via stale-while-revalidate so we stay well below Plausible's 600 req/h limit. WhenPLAUSIBLE_API_KEYis unset or the upstream fails, it returnspoints: []and the frontend shows a"visitor data unavailable"placeholder — distinguishing real zeros from missing data. The rest of the dashboard is unaffected because visitors load on a separate fetch.daily_implsadded to/insights/dashboard— zero-filled last-30-days series so the daily timeline does not need an additional admin-only endpoint.Configuration
Adds three settings to
core.config.settings(also in.env.example):PLAUSIBLE_API_KEY— Bearer token from Plausible → Account Settings → API Keys (optional; without it the chart degrades gracefully)PLAUSIBLE_SITE_ID— defaults toanyplot.aiPLAUSIBLE_API_URL— defaults tohttps://plausible.io/api/v2/queryPre-merge checklist
PLAUSIBLE_API_KEYin GCP Secret Managerroles/secretmanager.secretAccessorto the Cloud Run runtime service accountdo-not-mergelabelTest plan
uv run --extra test pytest tests/unit/api/test_routers.py tests/unit/api/test_insights_helpers.py— passes (incl. tests for the no-API-key path, upstream-failure path, and the Plausible response parsing with a frozen clock)yarn test(app/) — passes (incl. new tests for the visitors header label and the "unavailable" placeholder)yarn type-checkandyarn lint— cleanyarn build— succeedsuv run --extra dev ruff check/ruff format --check/mypy api core— all clean/insights/visitorsreturns real data oncePLAUSIBLE_API_KEYis provisioned/statsand verify the visitors chart renders and the daily-impls timeline matches the debug pageGenerated by Claude Code